Skip to content

[WIP] Sentinel v5#53

Open
BK1031 wants to merge 125 commits into
mainfrom
v5
Open

[WIP] Sentinel v5#53
BK1031 wants to merge 125 commits into
mainfrom
v5

Conversation

@BK1031
Copy link
Copy Markdown
Contributor

@BK1031 BK1031 commented Jan 17, 2026

Sentinel v5 rewrite, see design doc here for more info.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 17, 2026

Deploy Preview for gr-sentinel failed. Why did it fail? →

Name Link
🔨 Latest commit aba3aac
🔍 Latest deploy log https://app.netlify.com/projects/gr-sentinel/deploys/696b40e767b51e0008505343

@netlify
Copy link
Copy Markdown

netlify Bot commented Feb 24, 2026

Deploy Preview for gr-sentinel failed. Why did it fail? →

Name Link
🔨 Latest commit 63a1ebe
🔍 Latest deploy log https://app.netlify.com/projects/gr-sentinel/deploys/6a0b762e679d1300083c42b9

BK1031 and others added 24 commits February 24, 2026 01:23
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BK1031 added 30 commits May 14, 2026 18:26
RequireAuth wraps the AppShell route subtree. Without a stored session
it Navigate-redirects to /auth/login while preserving the intended
location in state.from so LoginPage can route the user back after
sign-in. Bare /, /applications, /groups, etc. now bounce to login.

api.ts gains two interceptors:
- request: attaches Authorization: Bearer <accessToken> from the stored
  session when one exists.
- response: on 401, calls /auth/refresh once (coalesced across
  concurrent in-flight requests via a shared promise), saves the new
  pair, and retries the original request. If refresh itself fails it
  clears the session and force-navigates to /auth/login. The refresh
  request URL is exempt so we don't loop.

LoginPage reads state.from?.pathname (set by RequireAuth) and routes
back there on success; defaults to / when arriving fresh or from
onboarding (?email=...).
…n header

Adds TanStack Query as the data layer. main.tsx wraps the app in a
QueryClientProvider with retry: 1 and refetchOnWindowFocus: false.

lib/auth.ts grows from storage helpers to also export an Entity type
(mirrors core's response shape) and a useAuth() hook that:
  - reads the local session synchronously
  - queries GET /core/entity/<entityId> via useQuery, keyed by entityId,
    5min staleTime, only enabled when a session exists
  - exposes { session, user, isLoading, isAuthenticated, refresh, logout }

logout() clears the session, drops the query cache, and force-navigates
to /auth/login. refresh() invalidates the currentEntity query so
mutations (e.g. settings edits, future) can call it and have all
consumers re-render.

AppHeader is the first consumer: drops mockUser and renders a
Skeleton avatar while loading, then the real name / email / avatar.
Sign-out wired to logout().
Adds the two public entity endpoints that frontends and authenticated
third-party clients should use, replacing direct hits to /core/entity/:id
(which is now strictly service-to-service).

Both handlers gate via the existing AuthChecker + Require() idiom:
  - GetMe (/entities/@me) requires user:read scope; resolves the entity
    from the bearer's subject claim.
  - GetEntity (/entities/:id) requires user:read AND the URL :id must
    match the bearer's entity_id. Self-only until shared-group / admin
    scope authz lands.

Two new accessors in api.go bottom: GetRequestTokenEntityID,
RequestTokenHasEntityID, RequestTokenExists. Rincon route prefix
/entities/** registered for core, kerbecs gets /api/entities/* upstream.

Web's useAuth swaps from GET /core/entity/:id to GET /entities/@me — no
longer needs to know its own entity_id at call time (the bearer is the
identity).
…ntities/:id

Third-party apps still gated on user:read scope and matching entity_id;
first-party tokens (audience sentinel) skip the id check so team
directory / cross-entity reads from the Sentinel web app work without
a dedicated admin scope. When a real cross-user scope (eg users:read)
is needed for non-sentinel callers, add it to the Any() chain.
Reads the first_name from the live entity (via useAuth) instead of
mockUser, with a skeleton placeholder while the query is in flight.

Recently-accessed and recent-activity sections still mock — they need a
per-user logins endpoint (we dropped /entities/@me/logins earlier) and
a way to fetch app metadata for each distinct client_id in the user's
history.
Cloudflare sets CF-Connecting-IP server-side (unspoofable as long as the
origin only accepts traffic from CF's IP ranges) so we can rely on it
for the real client IP without configuring trusted proxies in gin. Falls
back to c.ClientIP() in dev where no CF is in front.

Helper lives at the bottom of oauth/api/api.go; all three entity-login
recording sites (login, refresh, OAuth code exchange) swap over.
Public endpoint exposing the user's session history. Filters via query
params:
  client_id  match one application's logins (eg sentinel, blix)
  scope      exact-match scope string
  before     RFC3339 cutoff, returns rows created before this time
  after      RFC3339 cutoff, returns rows created after this time
  limit      integer cap, default unlimited

service.GetEntityLogins refactored to take an EntityLoginsFilter struct
so the parameter list doesn't grow unbounded; existing
/core/entity/:entityID/logins caller updated.

Authz follows the /entities/:id pattern: aud=sentinel (first-party app)
bypasses self-only; third-party tokens need user:read scope AND must
match the bearer's user_id claim. Two new helpers in api.go bottom:
GetRequestTokenUserID, RequestTokenHasUserID.
Replaces mockRecentLogins with a useQuery against
GET /users/{user_id}/logins?limit=5, keyed off the user id from
useAuth(). Skeleton rows while in flight, empty-state copy when there
are no logins, then real rows showing the client_id, scope, ip_address
and created_at.

Recently-accessed apps still mocked — needs per-app-metadata fanout
(dedupe client_ids in logins + bulk fetch /applications/client/:id),
which is a separate piece of work.
After logins resolve, dedupe client_ids and fire one
GET /applications/client/<cid> per unique value (cached 5min). Display
becomes 'client_id · App Name' with the name in muted text when the
fetch succeeds; falls back to just client_id otherwise.

Uses useQueries so all the per-app fetches happen in parallel and share
the React Query cache with anywhere else we might fetch the same app.
…tly Accessed

Per-user 'apps signed into, ordered by most-recent-access' aggregated
server-side via DISTINCT-ON-equivalent (GROUP BY client_id with MAX
created_at) joined to the application table. Single query returns
denormalized {Application, last_accessed_at} so the frontend skips the
client-side dedupe + per-app fanout that would have missed apps with
lopsided login distributions (50 sentinel logins drowning out one Blix
login last week).

  service.GetAccessedApplicationsForEntity(entityID, limit)
    returns []AccessedApplication{model.Application, last_accessed_at}
  GET /api/users/:id/applications?limit=N
    same authz as /users/:id/logins (aud=sentinel bypass OR
    user:read + matching user_id)

HomePage swaps mockApplications for a useQuery against the new endpoint.
Skeleton tiles while loading, friendly empty copy when there are no
logins yet, real AppCard grid otherwise.
…tions

Disambiguates from a future /users/:id/applications endpoint that would
list apps the user owns (registered as OAuth clients). The frontend
useQuery key, handler name, and route all rename in lockstep.
Distinct from redirect_uris (OAuth callbacks) — this is where to send
the user to actually open the app, used by the dashboard's Recently
Accessed tiles and anywhere else we surface a clickable app.

  model.Application gains LaunchURL string
  init job sets the Sentinel app's launch_url to
    https://sso.gauchoracing.com on first create
  HomePage AccessedApplication response type adds launch_url, maps to
    the Application.url field the AppCard renders

Existing rows: AutoMigrate adds the column nullable; backfill the
Sentinel row in dev manually if needed. When other apps are registered
they should set launch_url at create time.
Previously rsa.GenerateKey was called in InitializeKeys every time core
booted, so every air-triggered rebuild invalidated every active session.
Users were getting logged out on every code change.

Adds model.SigningKey with Active boolean. InitializeKeys now:
  - loads the active key from signing_key table if one exists
  - otherwise generates a fresh keypair, PEM-encodes it, persists with
    Active=true, and uses it

The Active flag is unused today (always one active key) but is the
schema we need for rotation: mint a new active key, mark the old one
inactive, expose both public halves via JWKS until old tokens age out.
That work adds a kid header to JWTs and per-kid lookup in ValidateToken
but doesn't require a migration.

Sessions now survive core restarts cleanly.
Replaces the 'Coming soon' stub on /applications with a real listing
backed by GET /applications. Search input filters client-side across
name/description/client_id; results sort alphabetically. Loading
skeletons, empty-state copy for both 'no apps registered' and 'no
matches'.

Pulls AppCard out of HomePage into components/AppCard.tsx so both pages
share it. The shared card accepts the API Application shape directly
(snake_case fields, launch_url for the link, icon_url with fallback to
the gradient + initial). Optional lastAccessedAt prop renders the
'Last accessed Xh ago' footer only on the dashboard's Recently Accessed
section.

New lib/applications.ts holds the Application TS type mirroring the
core JSON shape — same pattern as lib/auth.ts's Entity type.
Clicking a tile (dashboard Recently Accessed or full Applications grid)
now opens /applications/:id rather than launching the external app in a
new tab. The details page is where the user can see the app's metadata
and explicitly launch from there.

  components/AppCard becomes a <Link to=...>, chevron icon on hover
  pages/applications/ApplicationDetailsPage.tsx
    - useQuery GET /applications/:id
    - skeleton while loading, "not found" on miss
    - header with icon, name, description, Launch outline button
    - rows: client_id, launch URL, redirect URIs, registered date
…d vs browse behavior

Two click affordances are different concerns:
  - Dashboard (Recently Accessed) — user wants to jump back into the app
    they were using. LaunchAppCard opens launch_url in a new tab with
    the external-link icon as the hover affordance.
  - Applications page — user wants to inspect details (client_id,
    redirect URIs, last-launched, etc.) before launching. AppCard
    navigates to /applications/:id with a chevron-right hover icon.

Leaves room for both to diverge as we add more variant-specific UI
(launch counts on the dashboard card, ownership badges on the browse
card, etc.) without one component swelling into a config soup.
Backend
  Splits CreateOrUpdateApplication into:
    POST /applications  — create, response includes client_secret once
    PUT  /applications/:id — update name/description/icon_url/launch_url
  Create handler builds a createdApplicationResponse that embeds
  model.Application and adds a separate Secret field (json:client_secret)
  since model.Application JSON-skips client_secret on subsequent reads.

Web
  /applications/new      ApplicationNewPage — form, on success shows
                          client_id + client_secret in a dialog with
                          copy-to-clipboard buttons before routing to
                          the new app's details page
  /applications/:id/edit ApplicationEditPage — fetches existing, prefills
                          form, PUTs the edits, invalidates the index +
                          details queries
  /applications          gains "New application" CTA in the header
  /applications/:id      gains "Edit" button next to "Launch"

Redirect URI management is still TODO — we have add/remove endpoints
on the backend but no UI for them on the edit page yet.
Adds GET /applications/:id/secret (gated on aud=sentinel — first-party
only) so the secret can be retrieved on demand without leaking through
every app read. model.Application keeps json:- on ClientSecret so list
and by-id reads stay secretless.

Details page gains a Client Secret row with masked dots, an eye toggle
that triggers the secret fetch (useQuery enabled on toggle, 5min cache),
and a copy button. Client ID row gains a copy button to match.

Authz tightens later when we have application ownership — at that point
the gate becomes ownership OR sentinel:all instead of aud=sentinel.
Details page now reads as three cards instead of one bordered row stack:
  - OAuth credentials  (Client ID + masked secret with reveal/copy)
  - Redirect URIs      (list with copy buttons, link to edit when empty)
  - Metadata           (Launch URL, Registered, Last updated)
Header gets a wrap-friendly layout so the action buttons reflow on
narrow screens.

Edit page becomes two cards:
  - Basic info        (name, description, launch_url, icon_url + save)
  - Redirect URIs     (list with X-to-remove, inline form to add)
Redirect changes fire POST/DELETE /applications/:id/redirect-uris and
invalidate the by-id query so both edit and details refresh.

Adds a CopyableMono helper for the mono-font value + copy-button rows
that appear in three places now (client_id, secret, each redirect URI).
Backend
  CreateApplication now Requires a bearer and sets OwnerID from
  GetRequestTokenEntityID(c). The init job sets the bootstrap Sentinel
  app's OwnerID to the Sentinel core entity so it isn't orphaned.

Web
  Application TS type already had owner_id; Entity type gains the
  service_account variant (mirror of model.Entity.ServiceAccount, also
  omitempty server-side). Details page Metadata card adds a "Created by"
  row that fetches GET /entities/<owner_id> via useQuery (5min cache)
  and renders the user's full name, or the service account name when
  the owner is a service entity.

Existing rows in dev: backfill the Sentinel app manually with
  UPDATE application SET owner_id = '<sentinel_core_entity_id>'
  WHERE id = '<sentinel_app_id>' AND owner_id = ''
Reads (GET /applications, /applications/:id, /applications/:id/groups,
/applications/:id/redirect-uris):
  Require aud=sentinel OR applications:read scope

Writes (POST /applications, PUT /:id, DELETE /:id, group + redirect-uri
mutations):
  ApplicationWriteAuthorized helper:
    sentinel:all
    OR aud=sentinel AND bearer's entity_id == app.owner_id
    OR applications:write scope AND bearer's entity_id == app.owner_id
  Loaded application is fetched before the gate runs so the owner
  comparison has data.

Secrets (GET /applications/:id/secret):
  Stricter than reads — sentinel:all OR aud=sentinel + owner only.
  A third-party app with applications:read can't get any app's secret.

Create (POST /applications):
  aud=sentinel OR applications:write. No owner check (no app yet).

GET /applications/client/:clientID stays ungated — oauth's authorize
flow hits it over the docker network with no bearer. When we add
service-to-service client_credentials tokens, this can be locked down
behind sentinel:all.

ApplicationWriteAuthorized helper added at the bottom of application.go.
The dialog was a one-time-show of the freshly minted secret. Now that
the details page has an eye-toggle reveal that fetches on demand, the
'save this now' semantics are gone — the secret is retrievable anytime
by the owner. Submitting just toasts 'Application created' and routes
to the details page.

Drop CreatedApplication response type + Dialog imports + the secret/id
copy buttons + the dismiss-to-navigate dance.
The response interceptor was treating every 401 as 'your bearer
expired, try refreshing' — including the 401 from a failed login. With
no session in localStorage, the refresh branch fell through to
window.location.href = '/auth/login', a hard reload that wiped the
controlled-input state and skipped LoginPage's catch + toast.

Exempt /auth/login and /auth/refresh from the refresh-retry path so
their 401s propagate directly to the caller. Also drop the
clearSession+force-redirect when there's no refreshToken at all —
just propagate; RequireAuth catches the un-authed case on its own.
Both oauth and discord wrapped every sentinel.* call's failure into a
single 'POST /foo returned 401' string. The login handler then
collapsed *any* error into 'invalid credentials' — masking
'rincon couldn't resolve the route' and 'core was unreachable' as if
the user typed the wrong password.

New sentinel.APIError exposes:
  Method, Route        for context in error strings
  Status               0 when no HTTP response was received (rincon
                         resolution failure, transport error); the real
                         status code otherwise
  Body, Message        raw body and the parsed {"error": "..."} field
  Err                  underlying transport/resolution error, Unwrap-able

Centralized the request flow into a single do() so Get/Post/Put/Patch/
Delete are thin wrappers around it.

oauth's LoginEmailPassword now errors.As checks the APIError: 401 from
core surfaces as 401 'invalid credentials' to the user; anything else
surfaces as 502 'auth service unavailable' with full upstream detail
logged. Callers in the rest of the codebase still get a plain error
back via the interface — only the ones that care about distinguishing
upstream status pull out APIError.

Same shape mirrored in discord/pkg/sentinel.
…al cause

Previously APIError.Status == 0 collapsed two distinct failure modes:
'rincon couldn't resolve the route' (no upstream registered) and
'reached rincon, but the transport call to the resolved service died.'
Login surfaced both as 'core service is unavailable' even though only
the second is literally true.

Adds two sentinel-side error sentinels:
  ErrRinconUninitialized  rincon client never connected
  ErrRouteResolution      rincon doesn't know who serves this route

resolveURL wraps the right one. errors.Is in the login handler now
picks the right user-facing message:

  invalid credentials              401 from core
  service registry not initialized rincon client never came up
  core route not registered ...    rincon doesn't know about core
  core service is unreachable      everything else (transport, 5xx)

Same shape mirrored in discord/pkg/sentinel for when discord starts
hitting more core endpoints (consume orchestration etc.).
…gorizing

APIError.Error() already builds a descriptive string with method, route,
status (or underlying cause when no HTTP response). The login handler's
switch was redundant — anything that isn't 'core said 401' just bubbles
the sentinel error verbatim.

  POST /core/login/email-password: rincon could not resolve route: ...
  POST /core/login/email-password: rincon client not initialized
  POST /core/login/email-password returned 500
  POST /core/login/email-password: dial tcp ...: connection refused

ErrRinconUninitialized and ErrRouteResolution stay in the pkg for any
future caller that wants to errors.Is on them.
OrbStack injects HTTP_PROXY=http://proxyproxy.orb.internal:8305 into every
container. Go's net/http honors it, so container-to-container calls (notably
to rincon:10311 for service registration and route resolution) get routed
through the host proxy, which can't resolve docker-internal service names
and returns 502. Result: silent rincon registration failures and broken
inter-service routing.

Neutralized via a shared YAML anchor merged into each service's environment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant